跳转至

6 Intro to Custom Animations

In this book, you’ve explored many ways SwiftUI makes animation simple to achieve. By taking advantage of the framework, you created complex animations with much less effort than previous app frameworks required. For many animations, this built-in system will do everything that you need. However, as you attempt more complex animations, you’ll find places where SwiftUI can’t do what you want without further assistance.

Fortunately, the animation support in SwiftUI includes protocols and extensions that you can use to produce animation effects beyond the basics while still having SwiftUI handle some of the work. This support lets you create more complex animations while still leveraging SwiftUI’s built-in animation capabilities.

In this chapter, you’ll start by adding a standard SwiftUI animation to an app. Then you’ll learn to implement animations beyond the built-in support while having SwiftUI handle as much as possible.

Animating the Timer

Open the starter project for this chapter. You’ll find an app that helps users brew and prepare tea. Build and run the app and tap any of the provided teas.

img

The app lists several types of tea and provides suggestions on water temperature and the amount of tea leaves needed for the desired amount of water. It also provides a timer that counts down the time needed to steep the tea.

You’ll see information for brewing the tea you selected. Adjust the amount of water, and it’ll update the needed amount of tea leaves to accommodate the change. It also lets you start and stop a timer for steeping the tea. When you start the brewing timer, it begins a countdown until your steeping completes.

Once it finishes, a view tells you your tea is ready to drink.

img

While it works, the app lacks energy and excitement. You’ll add some animations that give it more energy and help users in their quest for the best tea.

First, the ring around the timer turns blue when you start the timer. While the color change does show the timer is running, it doesn’t attract the eye. To do so, you’ll animate the timer’s border as a better indicator of a running timer.

Open TimerView.swift, and you’ll see the code for this view. The CountingTimerViewused in this view contains the control for the timer. It currently uses overlay(alignment:content:) to add a rounded rectangle with the color provided by the timerBorderColor computed property. You’ll add a special case to display an animated border when the timer is running.

After the existing state properties, add the following new property:

@State var animateTimer = false

You’ll use this property to control and trigger the animation by toggling its value. The animation here will animate the border around the timer display and controls. You’ll animate the border so it appears to move in a circle around the digits and controls. To do this, you’ll create an angular gradient or conic gradient.

Unlike the more common linear gradient, which blends colors based on the distance from a starting point, an angular gradient blends colors as it sweeps around a central point. Instead of the distance from the starting point determining the color, the angle from the central point determines the color. All points along a line radiating from the center will share the same color.

Add the following code after the existing computed properties to create the angular gradient:

var animationGradient: AngularGradient {
  AngularGradient(
    colors: [
      Color("BlackRussian"), Color("DarkOliveGreen"), Color("OliveGreen"),
      Color("DarkOliveGreen"), Color("BlackRussian")
    ],
    center: .center,
    angle: .degrees(animateTimer ? 360 : 0)
  )
}

You specify the gradient will begin as a dark shade of black, transition to olive green at the midpoint, and then back to the same shade of black at the end. You set the gradient to use the center of the view as its origin. To allow animation, you set the angle by multiplying animateTimer by 360 degrees.

Toggling animaterTimer to true will rotate the gradient in a complete revolution. Note that the gradient will transition through a complete circle since you only specify a single angle. SwiftUI positions the start of the gradient at that angle and sweeps through the full rotation to the final color. It’ll provide a circular shift from nearly black through olive green around the circle and then back to nearly black, where the gradient started.

Now find the overlay modifier on CountingTimerView and replace its contents with:

switch timerManager.status {
  case .running:
    RoundedRectangle(cornerRadius: 20)
      .stroke(animationGradient, lineWidth: 10)
  default:
    RoundedRectangle(cornerRadius: 20)
      .stroke(timerBorderColor, lineWidth: 5)
}

While the timer runs, you apply a different style to stroke(_:lineWidth:) that uses the gradient you just added. You also widen the line to draw the eye and provide more space for the animation to show, and add another visual indicator that something has changed.

Now, build and run the app. Tap any tea and then start the timer. The border takes on the new broader gradient but doesn’t animate yet. You’ll do that in the next section.

img

Animating the Gradient

Still in TimerView.swift, find onChange(of:perform:) on NavigationStack. This modifier monitors changes to the timer’s status. Currently, it only checks for the .done state on the timer. Add a new case to the existing switch statement:

case .running:
  // 1
  withAnimation(
    .linear(duration: 1.0)
    // 2
    .repeatForever(autoreverses: false)
  ) {
    // 3
    animateTimer = true
  }

Here’s what you did:

  1. You create an explicit animation that produces a one-second linear animation. Using a linear animation produces constant motion that matches the flow of time. Setting the length to one second matches the rate at which the numbers in the timer change. Keeping the animation in sync with the number changes helps visually tie the two together.
  2. By default, the animation only occurs once when the state changes. You could do a continuous change of animateTimer, perhaps by tying it directly to the elapsed time. Still, there’s an easier way. repeatForever(autoreverses:) tells SwiftUI to restart the animation when it completes. By default, the animation would reverse before repeating. You pass false to autoreverses to skip the reversing of the animation.
  3. You change animateTimer to true. Since this occurs in the closure, it’ll animate the state change using the specified animation. The state changes cause the angular gradient to rotate one complete revolution, which will be animated.

Run your app, select any tea and start the timer. You’ll see the gradient rotates while the timer counts down.

img

Next, you’ll look at a similar animation using opacity to produce a pulsing effect when the user pauses the timer.

Animating the Pause State

You’ll also add an animation when the user pauses the time. First, add the following state property after the existing ones:

@State var animatePause = false

You’ll change this state property to trigger the animation when the user pauses the timer.

Now find overlay(alignment:content:) on CountingTimerView and add a new case for the .paused state:

case .paused:
  RoundedRectangle(cornerRadius: 20)
    .stroke(.blue, lineWidth: 10)
    .opacity(animatePause ? 0.2 : 1.0)

You added a new option for the case when the timer reaches the paused state. As with the others, you apply a stroke(_:lineWidth:), in this case, a blue line the same width as when running. You then apply opacity(_:) using animatePause to change it between 0.2 (almost transparent) to 1.0 (fully opaque).

Now find the onChange(of:perform:) modifier you worked in earlier. Add the following line right at the beginning of the .running case:

animatePause = false

This code resets the property when the timer begins running. It’s essential to ensure the animation is ready if triggered again.

Now you need to handle the new paused state. Still in onChange(of:perform:), add a new case to handle the .paused state:

case .paused:
  // 1
  animateTimer = false
  // 2
  withAnimation(
    .easeInOut(duration: 0.5)
    .repeatForever()
  ) {
    animatePause = true
  }

And replace the break in the default case with:

// 3
animateTimer = false
animatePause = false

Here’s what this code does:

  1. When your time switches to the paused state, you set animateTimer to false. Setting animateTimer back to its original state prepares it if the user starts the timer again.
  2. You use an explicit animation when setting animatePause to true. Recall this will change the opacity from 0.2 to 1.0. You apply an ease-in-out animation lasting one half-second. You also apply repeatForever(autoreverses:) using the default parameter for autoreverses, which will reverse the animation before repeating it. As a result, the animation will cycle from dim to bright and back once per second.
  3. If the timer status changes to any other state, then neither animation should be active, and you set both state properties to false.

Run your app, select any tea and start the timer. After a few seconds, pause the timer. You’ll see the border of the timer pulse.

img

These two animations work like many others you’ve seen, taking advantage of SwiftUI automatically handling the animation for a Bool value such as animateTimer. In the next section, you’ll learn how to handle more complex cases when SwiftUI can’t handle the animation for you. It’s time to look into the Animatable protocol.

Making a View Animatable

As mentioned in Chapter 1: Introducing SwiftUI Animations, an animation is a series of static images changing rapidly and providing the illusion of motion. When SwiftUI animates a shape, it rapidly draws the view many times. The type of animation and the elements that change determine how the views change. In the previous section, you changed the angle of the angular gradient and SwiftUI animated the result of that change.

SwiftUI can’t manage a change to a Path or a shift in the text shown in a Text view. In these cases, you can conform to the Animatable protocol and manage the animation yourself.

In this section, you’ll use Animatable to implement a text view that can animate the transition between two numbers. In these cases, you’ll turn to the underlying structure you’ve been using and directly implement what you need.

Behind the scenes of every SwiftUI animation lies the Animatable protocol. You turn to it when you can’t do what you want with just animation(_:) or withAnimation(_:_:).

This protocol has a single requirement, a computed property named animatableDatawhich must conform to the VectorArithmetic protocol. A value that conforms to this protocol ensures that SwiftUI can add, subtract and multiply the value. Many built-in types already support this protocol, including Double, which you’ll use in this chapter.

These two protocols allow SwiftUI to provide a changing value to animate independent of how the view implements the animation. SwiftUI calculates the new animatableDatavalues based on the kind of animation used. Your view needs to handle the values that SwiftUI sends to it. It lets you produce a single view that can handle any animation without worrying about the differences between linear or spring animations.

Create a new SwiftUI View file named NumberTransitionView.swift and open it. Update the definition of the generated struct to:

struct NumberTransitionView: View, Animatable {

Adding the Animatable protocol lets you provide direct control of the animated values. Next, add the following code to the top of the struct:

var number: Int
var suffix: String

var animatableData: Double {
  get { Double(number) }
  set { number = Int(newValue) }
}

Here, you create a property to hold the number the view will display as an Int. You’ll also let the user pass in a string to append to the number.

You then implement the animatableData required by the Animatable protocol as a computed property. This computed property gets or sets the value of number while converting between Int and Double as needed. In this case, given the range of values you’ll animate, you don’t need the extra resolution provided by the double.

Update the view’s body to:

Text(String(number) + suffix)

You display the number and append the passed suffix to the end. Finally, update the preview to provide a number and the suffix by changing it to:

NumberTransitionView(number: 5, suffix: " °F")

If you look at the preview, you won’t see much difference between this view and a regular text view showing a number.

img

The difference will only show when you animate the view. You’ll do that in the next section.

Using an Animatable View

Open BrewInfoView.swift. You’ll add a bit of animation to the brewing temperature that appears on the view. Add the following new property after the existing state properties:

@State var brewingTemp = 0

You’ll use this property to change the value displayed. Initially, you set it to zero, so you can change it when the view appears. Now attach the following modifier to the VStackbefore padding(_:_:):

.onAppear {
  withAnimation(.easeOut(duration: 0.5)) {
    brewingTemp = brewTimer.temperature
  }
}

You set the state property to the temperature passed into this view. You wrap this change inside an explicit call to withAnimation(_:_:) and specify an ease-out animation that lasts one half-second.

You choose the ease-out animation because the fast initial change of this type of animation makes the interface seem speedy. The short duration also gives the user enough time to see the animation while remaining quick enough so they don’t grow impatient.

Before implementing the animation, you’ll change the view so you can better compare it to the final animation. Find the line in the view that reads Text("\(brewTimer.temperature) °F") and change it to:

Text("\(brewingTemp) °F")

This change shows the new property instead of the brewing temperature passed into the view. So, the value will initially be zero and change to the final temperature.

Run the app and select any tea. When the view appears, you’ll see what you probably expected. The initial view showing the zero fades out, and the new view showing the desired temperature fades in. SwiftUI doesn’t know how to animate text changing from zero to a temperature, so it uses a view transition.

img

Change the text line to use your new view. Replace the view showing the brewing temperature with:

NumberTransitionView(number: brewingTemp, suffix: " °F")

Run the app and select any tea.

img

You’ll notice an immediate difference. Instead of a view transition, the number counts up from zero to the target temperature. You’ll also see the number change quickly at first before slowing as it reaches the final temperature. It gets to that final temperature after one half-second.

That’s the power of the Animatable protocol! It lets you make almost anything you can imagine animate with SwiftUI. You take care of the state change as before and let SwiftUI calculate the changed values. In your view, you accept the changing values through the protocol and show appropriate values.

In the next section, you’ll work on a more complex scenario to produce a better animation for the timer as it counts down.

Creating a Sliding Number Animation

Open CountingTimerView.swift. On the first line of the VStack, you’ll see the timer currently displays a string from timerManager. This string shows the remaining time formatted using a DateComponentsFormatter that shows only the minutes and seconds. The result provides the information, but it’s a bit plain.

img

Before digital timers, clocks often used mechanical movements that moved printed numbers to show time. In this section, you’ll create an animated version of this type of display for the steeping timer as it counts down. You’ll begin by creating a new view that shows each timer digit in a separate view.

Create a new SwiftUI View file named TimerDigitsView.swift. Add this new property to the top of the view:

var digits: [Int]

You’ll pass in the digits of the timer as an array of Int values. The first two store the minutes, and the last two values in the array store the seconds. This change will display these values individually and make each digit easier to animate. Add this code below the digits property:

var hasMinutes: Bool {
  digits[0] != 0 || digits[1] != 0
}

This computed property will return false only when both digits of the minutes are zero. You’ll use this to help format the numbers in this view. Now change the body of the view to:

HStack {
  // 1
  if hasMinutes {
    // 2
    if digits[0] != 0 {
      Text(String(digits[0]))
    }
    // 3
    Text(String(digits[1]))
    Text("m")
  }
  // 4
  if hasMinutes || digits[2] != 0 {
    Text(String(digits[2]))
  }
  // 5
  Text(String(digits[3]))
  Text("s")
}

Here’s what this view does:

  1. You check the computed property and see if the time contains minutes. If not, then you skip displaying information about the minutes.
  2. When minutes exist, you display the first digit as long as it’s not zero.
  3. You always show the second digit of the minutes followed by the letter m to indicate this value shows minutes.
  4. If the first seconds digit is zero or if you had minutes, you show the first seconds digit. This condition will display a leading zero only when the time contains minutes.
  5. You always show the seconds digit and an s string to indicate these show seconds.

Update the preview to pass in digits. Change the preview to:

TimerDigitsView(digits: [1, 0, 0, 4])

Open CountingTimerView.swift and look for the code that reads Text(timerManager.remaingTimeAsString). Replace it with:

TimerDigitsView(digits: timerManager.digits)

Now you use this new view to show the time. The digits property of timerManagerformats an array with the desired data based on the remaining time.

Run the app, select a tea and start the timer. The view looks similar to the original, except now, each digit is a separate view instead of a single Text view showing a formatted string.

Visually, the most noticeable difference is more spacing between the letters and numbers indicating the time.

img

Now you’ll build a view to animate these individual digits. Create a new SwiftUI View file named SlidingNumber.swift. Open the new view and change the definition of the struct to:

struct SlidingNumber: View, Animatable {

As a reminder, adding Animatable tells SwiftUI that you’ll make this view support animation. As before, you implement the animatableData required by the protocol. Add this code to the top of the struct:

var number: Double

var animatableData: Double {
  get { number }
  set { number = newValue }
}

You store the value sent by SwiftUI in a Double property named number. You might wonder why you need a Double here instead of the Int you used in the last section, even though you’ll only display single integer digits.

The reason comes down to the granularity of the data. In the previous section, you produced animation between far apart integers. Here, you’ll change between adjacent digits. To create a smooth animation, you need the fractional values between the two numbers.

Update the preview to read:

SlidingNumber(number: 0)

With this view in place, you have the foundation to animate the timer. In the next section, you’ll examine how to get the desired effect.

Building an Animation

When developing an animation, it helps to consider the visual effect you want to achieve. Go back to the inspiration of a sliding scale of digits. You’ll implement a strip of numbers starting at nine and then moving down through zero. When the digit changes, the strip of numbers shifts to show the new value.

In SwiftUI terms, you want a vertical strip of the numbers around the new value. When the number changes, SwiftUI will provide a series of values between the original and new number through animatableData.

Look at this example where number begins at four and changes to three.

img

The series provided through animatableData begins at four and will decrease to three though the exact values will vary depending on the type of animation. The first value is slightly below four. The fractional part of the number indicates how far you’re through the change in the digit and begins as near one and approaches zero.

As that fractional part decreases, you shift the number upward toward the new number.

Once the number reaches the new value of three, the view resets so that the central value is that new number. The cycle can then repeat when the number changes again. With that background, you can now implement it in SwiftUI in the next section.

Implementing Sliding Numbers

First, you need a vertical strip of numbers. Delete the existing Text view inside the body, and add the following code at the top of the view body:

// 1
let digitArray = [number + 1, number, number - 1]
  // 2
  .map { Int($0).between(0, and: 10) }

This code calculates the numbers to show:

  1. You create an array of the number after the current number, the current number and the numbers below the current number. If number is four, the array would contain [5, 4, 3]. This array lets the animation flow in both directions.
  2. You use map on the array to convert the values to integers and remove that fractional amount from the Double. You also use between(:and) from IntegerExtensions.swift to handle the edge cases. The value below zero is nine, and the value above nine is zero.

Add the following code below what you just added:

let shift = number.truncatingRemainder(dividingBy: 1)

You use truncatingRemainder(dividingBy:) to get only the fractional part of the Double. As mentioned in the last section, this indicates how far through the animation you are. Next, add:

// 1
VStack {
  Text(String(digitArray[0]))
  Text(String(digitArray[1]))
  Text(String(digitArray[2]))
}
// 2
.font(.largeTitle)
.fontWeight(.heavy)
// 3
.frame(width: 30, height: 40)
// 4
.offset(y: 40 * shift)

This code implements the steps discussed in the last section. Here’s how each part creates part of the animation:

  1. To create the strip of digits, you use a VStack showing the integers you stored in digitArray.
  2. You apply the .largeTitle font with a heavy weight to let the digits stand out.
  3. You set the frame for the view to 30 points wide and 40 points tall. The height matches the distance between digits in the VStack.
  4. You take the shift you calculated earlier as the portion of the height that the view should shift for the current point in the animation. You multiply it by 40, the distance between digits in the stack. That converts the shift into an amount of vertical movement for the view.

Now you need to use this new view. Open TimerDigitsView.swift and change the body to:

HStack {
  if hasMinutes {
    if digits[0] != 0 {
      SlidingNumber(number: Double(digits[0]))
    }
    SlidingNumber(number: Double(digits[1]))
    Text("m")
  }
  if hasMinutes || digits[2] != 0 {
    SlidingNumber(number: Double(digits[2]))
  }
  SlidingNumber(number: Double(digits[3]))
  Text("s")
}

This code replaces the Text views from earlier with your new SlidingNumber view. Run the app, select any tea and start the timer.

In this state, you’ll see the entire strip of digits. As it animates, note that the strip shifts and how new numbers appear and vanish as the animation progresses.

Use the Slow Animations option in the simulator to help.

img

Once you watch the animation, you’ll finish cleaning up the view. Open SlidingNumber.swift. Add two more modifiers after offset(x:y:):

// 1
.overlay {
  RoundedRectangle(cornerRadius: 5)
    .stroke(lineWidth: 1)
}
// 2
.clipShape(
  RoundedRectangle(cornerRadius: 5)
)

Here’s what these do:

  1. You give the digit a thin frame using stroke(lineWidth:) applied to a RoundedRectangle.
  2. While there is a strip of numbers, you only want to show a single number at a time. You do this using clipShape(_:style:) with RoundedRectangle that matches the one used to produce the frame in step two. This shape fills the frame and clips to the frame you applied to the view. Clipping removes any elements outside that space and hides the extra digits in the VStack.

Run the app and start a steeping timer. You’ll see only a single digit that animates as the timer changes. It also has a nice surrounding border that helps each number stand out.

img

Challenge

Using what you’ve learned in this chapter, adjust the timer animation so the digits slide in the opposite direction and the numbers slide downward. As a hint, recall that in SwiftUI, a decrease in offset will cause a shift upward. How can you make that move down instead?

Check the challenge project in the materials for this chapter for one solution.

Key Points

  • An angular gradient shifts through colors based on the angles around a central point.
  • The Animatable protocol provides a method to help you handle the animation within a view yourself. You only need to turn to it when SwiftUI can’t do things for you.
  • When using the Animatable protocol, SwiftUI will provide the changing value to your view through the animatableData property.
  • When creating custom animations using the Animatable protocol, begin by visualizing what you want the finished animation to look like.
  • Take advantage of SwiftUI’s ability to combine elements. In many cases, breaking an animation into smaller components will make it easier. You’ll find it easier to animate individual digits instead of trying to animate an entire display of numbers.

Where to Go From Here?

  • You can read more about angular gradients in Apple’s Documentation.
  • You can find other examples of using the Animatable protocol in Getting Started with SwiftUI Animations.
  • You’ll also explore the Animatable protocol more in the next section, including learning how to deal with animations involving multiple elements.